iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 21

Day 21:業務邏輯客製與 Plugin 的四個時點

  • 分享至 

  • xImage
  •  

Day 21:業務邏輯客製與 Plugin 的四個時點

昨天結尾停在一個情況:一家公司要的差異大到語系與版面都接不住。那類需求的形狀跟前兩篇不一樣:存檔前多一道信用額度檢查、存檔後把單據同步到另一套系統、刪除時通知倉儲。它們要的不是一份長得不一樣的定義,是一段會執行的行為。

框架原本就有一條路能接:繼承整張表單的 BO,在註冊表裡換掉綁定。但這條路對上面那幾件事過重。為了存檔後發一封通知,得接管整張單據的 BO;三個互不相干的客製需求疊起來,只能寫進同一個子類;套裝日後在那個步驟新增的邏輯還跑不跑得到,取決於子類記不記得呼叫 base。缺的不是擴充點,是一種比繼承輕、而且能逐份客製宣告的掛法。

本篇說明:

  1. plugin 掛在哪四個時點,各自適合放什麼
  2. 設定檔為什麼連時點一起寫,一個類別一個時點又換到什麼
  3. 兩層相加之後,失敗與對外的副作用該怎麼放
  4. 什麼時候該用 plugin,以及它跨不過的那條線

一、Plugin 的四個時點

能掛的位置開幾個、開在哪裡,是這種機制要答的第一個問題。

框架開了四個:BeforeSaveAfterSaveBeforeDeleteAfterDelete。把它們放回 Day 14 那條寫入管線,位置是這樣:

DoBeforeSave
    BeforeSave        ← plugin
[擷取變更集]
DoSave                ← transaction 只涵蓋這一句
[變更稽核]
DoAfterSave
    AfterSave         ← plugin

方括號是框架自己做的事,不是擴充點。刪除那一側是同一個形狀:DoBeforeDeleteBeforeDeleteDoDelete → 刪除稽核 → DoAfterDeleteAfterDelete,其中 BeforeDeleteAfterDelete 是 plugin 的時點。

時點 緊接在哪一步之後 適合放什麼
BeforeSave DoBeforeSave 四個裡唯一能安全改資料的
AfterSave DoAfterSave 通知、對外同步
BeforeDelete DoBeforeDelete 對著整筆快照做的檢查
AfterDelete DoAfterDelete 把刪除傳播出去

四個時點都在 transaction 之外,所以 Day 14 那條邊界照樣適用:在這裡讀到的值,寫進資料庫的當下不保證還成立。真的要擋住另一個同時在存檔的人,只能讓資料庫在寫入的當下自己判定:一句帶著前提的 UPDATE、一個唯一索引,或一條 check constraint。

每個時點都接在對應的 Do 步驟之後,而那一步最後跑的可能是套裝的 base,也可能是客製 BO 覆寫過的版本。繼承與 plugin 在 Day 14 那條軸線上是相鄰的兩層,差的只是粒度與控制權,兩者因此可以一起用。

BeforeSave 特別的地方在於它排在擷取變更集與寫入之前,所以在那裡改的值會存進資料庫,也會留在稽核軌跡裡。再往後一格就不行了:資料已經寫完,AfterSaveDataSet 沒有效果,要改的是呼叫端會拿到的 RefreshedDataSet。這條分界 Day 14 在 SaveContext 上畫過,plugin 拿到的是同一個物件。

刪除那一側的快照也是 Day 14 講過的,這裡只補一句:框架在決定要不要載入它的時候,把有沒有 plugin 也算了進去。因為資料刪掉之後,只剩 AfterDelete 還看得到被刪掉的是什麼。


二、時點寫在設定檔上,類別必須對得上

哪一段客製跑在哪一個時點,這件事要在設定檔上看得見。PluginSettings 因此一列寫兩件事,型別與時點:

<PluginSettings>
  <Items>
    <ProgramPluginItem ProgId="Order">
      <Plugins>
        <PluginItem Type="Acme.Plugins.CreditLimitCheck, Acme.Plugins" Stage="BeforeSave" />
        <PluginItem Type="Acme.Plugins.OrderSync, Acme.Plugins" Stage="AfterSave" />
      </Plugins>
    </ProgramPluginItem>
  </Items>
</PluginSettings>

一個 ProgId 對一串 plugin,每一個 Stage 都有它自己的類別,一個類別也只掛一個時點。例子裡因此是兩個類別:存檔前那道檢查與存檔後那次同步本來就是兩件事。分開之後順帶換到各自可以被挑走,另一家客戶也在用同一套外部系統,把 OrderSync 宣告上去就好,不必連信用額度檢查一起搬。

類別那一邊要對得上:繼承 FormBusinessPlugin,把基底要的呼叫參數往上傳,覆寫設定檔上宣告的那一個時點。

public sealed class CreditLimitCheck(IBeeContext ctx, Guid accessToken, string progId)
    : FormBusinessPlugin(ctx, accessToken, progId)
{
    public override void BeforeSave(SaveContext context) { /* 額度不足就丟例外 */ }
}

三、相加之後,失敗往哪裡跑

兩層相加的規則與代價前天已經寫完:套裝鏈先跑、客製鏈接在後面,客製層沒有辦法停用套裝宣告的 plugin。相加之後還有一件事要決定:這條鏈上任何一段失敗了怎麼辦。

兩種失敗都往上拋,不略過、不降級:

失敗的種類 處置
宣告的型別載不到 直接拋,整個操作失敗
執行期丟出例外 往上拋,中止操作

這與 BO 那一軸是同一個答案,理由卻不一樣。那一邊不留退路,是因為退回框架預設的型別照樣建構得起來,那支程式於是安靜地表現得很通用;plugin 這一邊是因為它本來就是有人刻意加上去的,略過它等於客製整段沒有生效,而靜默漏掉一段信用額度檢查,比拒絕存檔難處理得多。Day 15 談註冊表的失敗策略時,判準是「故障的面貌」,這裡是同一條判準的另一個實例。

對外的副作用要落在哪一邊

框架只管執行順序:照設定檔宣告的先後把鏈上的 plugin 叫過一遍,中間不接例外、也不判斷該不該繼續。所以一段 plugin 出錯之後怎麼辦,是寫它的人決定的,而選擇只有兩個:直接拋出,整個操作中止;或是自己接住、記一筆錯誤,流程照常走完。

一般的分法照時點走,Before 拋、After 記。Before 那兩個時點多半在做驗證,擋不下來就不該讓它存進去;After 那兩個時點資料已經提交了,這時丟例外等於對著已經存好的資料回報失敗,而且行程要是在 plugin 跑完之前掛掉,結果是資料在、同步沒發生、也不留痕跡。

After 最常見的用途是把異動同步到另一套系統,那也是最容易寫錯的地方:在那裡拋例外,等於對方系統維護中的時候使用者連單都存不了。框架沒有為這件事加機制,加了也會被一個包住整段的 try-catch 繞過去;它只把責任講明白:別讓外部系統的可用性決定一筆資料能不能存檔。

可靠性要求因此把位置分成兩種:

可靠性要求 該落在哪裡
不能漏(財務、庫存、對外承諾) 在 transaction 內登記一筆待送記錄,另外由背景工作送出
盡力而為,或另有對帳作業補得回來 AfterSave / AfterDelete 的 plugin 直接送

第二列現在就做得到。第一列還不行:框架裡沒有這樣的實作,而 Day 14 寫過的那六個寫入步驟,也沒有一個能在既有的 transaction 上追加語句。框架說得出這件事該落在哪一邊,但還沒有把那個位置開出來。


四、什麼時候該用 plugin

一家客戶提出需求的時候,第一個要問的是這件事能不能靠改定義解決。欄位標題換個說法、畫面上少三個欄位、選單照他們的組織排,這幾種前兩篇談過,改的是一份定義,不必動程式。

把常見的需求排在一起,界線就清楚了:

想改的東西 套裝層 客製層
欄位標題、下拉選項的文字 改語系檔 改客製語系檔
畫面上少三個欄位、明細換一種排法 改版面檔 改客製版面檔
選單長得不一樣 改選單檔 改客製選單檔
多一個欄位 FormSchemaTableSchema 沒有這條路
改一條計算規則或驗證規則 FormSchema 裡的算式 掛 plugin,或換掉整個 BO

前三列在兩層都成立,就是 Day 1 那句話:定義是資料,改定義不必重編、不必換組件。第四列只在套裝層成立,理由前天寫過:FormSchema 同時決定資料表長什麼樣,逐份客製分歧的話,實體結構會跟著裂開。

最後一列才是 plugin 的位置。規則與計算式宣告在 FormSchema 裡,而那份定義不能客製,所以一家客戶要改一條算式,只能落回程式碼。開頭那幾個需求也一樣:定義描述的是資料長什麼樣,裡面沒有地方放一段會執行的行為。

落回程式碼之後,plugin 是最輕的那一種:不必接管整張單據、可以疊加、可以逐份客製宣告;但它終究是一個編譯後的型別,要編譯、要打包、要放進主機的 bin 才會生效。同一件事在套裝層是改一行算式,到了客製層就得走這一條交付路徑。「不更版」這件事目前完整成立的範圍是套裝層。

時間拉長還有另一面。一張表單用個幾年,上面會累積好幾個 plugin,而真正會被重複用到的,多半是跟外部系統對接的那幾個:把客戶資料同步到 CRM、把單據送進 BPM 簽核、把交期寫進 Outlook 或 Google 行事曆。這一種綁的是對方系統,不是某一家客戶,所以新客戶只要也在用那套系統,把它宣告上去就能用,等於在既有的清單裡挑幾列。

各家自己的業務規則沒有這個性質。信用額度怎麼算、單號怎麼配,多半一家一個樣,寫了通常也只有那一家在用。但這一類累積起來仍然值得定期盤一次:如果好幾家都寫了同一種行為,那就不是客製,是還沒被收斂的共通需求。


小結

plugin 是繼承與定義檔之間的那一格。不接管整條流程,只在四個固定的位置各加一段;兩層相加,套裝日後補的自動生效;一個類別一個時點,設定檔上讀得出哪一段跑在哪裡。

  • 四個時點全部在 transaction 外,所以 Day 14 那條邊界照樣適用:這裡讀到的值在寫入當下不保證還成立,而 BeforeSave 是唯一能安全改資料的位置
  • 失敗的處置與 BO 那一軸一致,兩邊都不留退路,但怕的東西不同:那一邊怕程式安靜地表現得很通用,這一邊怕客製整段沒生效
  • 不能漏的對外同步該落在 transaction 內,而那個位置目前還沒開出來;After 時點承接得了的是盡力而為、或另有對帳作業補得回來的那一類

這一章三篇問的是同一件事的三個面向:一家公司要不一樣的地方,最後被表達成什麼。表達成定義,它就能疊加、能退回、能跟著套裝一起演進;表達成型別,它就得編譯、打包、進到主機的 bin,然後回到 Day 1 那條「需求的大小,與交付的成本,徹底脫鉤」的老路上。

所以判準不是這個擴充機制能做多少事,是它要走哪一條交付路徑。plugin 把客製的門檻從「接管一整張單據」降到「加一個類別」,這是實質的一級,但它跨不過定義與程式碼之間那條線。客製化這條線接下來要往前推的,就是把更多「不一樣」從程式碼那一側搬回定義這一側。

明天起換一個層面,先從一份物件同時要能存成檔案、送給瀏覽器、又要在行動端讀得回來這件事開始。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 20:多語系客製與資源結構
下一篇
Day 22:XML、JSON 與 MessagePack 的分工
系列文
ERP 架構師筆記:定義驅動的框架設計26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言